Procedural Terrain Generation Using Voronoi Diagrams and More
Author
Batsambuu Batbold
Published
December 16, 2025
Abstract
Hand-crafting a massive video game world is an incredibly time-consuming task. Is there a way to let the computer build it for us? In this project, we explore Procedural Content Generation, using algorithms to create unique landscapes automatically. While many traditional approaches use square grids, these often look blocky and artificial. We aim to fix this by creating terrain that feels organic. We replace rigid squares with Voronoi diagrams to create natural shapes, and we use Perlin noise to generate realistic mountains and valleys. We also implement a system to assign biomes, such as forests and deserts, based on the simulation of height and moisture. Finally, we bring the map to life by rendering it in 3D using Blender. This project demonstrates how we can turn mathematics into explorable worlds.
Figure 1
[TODO: Include final 3D screenshot]
Introduction
As a fan of open-world and simulation video games, the setting and environment are the biggest factors in deciding if a virtual world is worth my time. We, as players, expect vast, immersive, and explorable worlds that offer a unique experience. Who wants to keep coming back to the same place if it offers nothing new? However, hand-crafting these worlds is a huge task. It often takes longer to build than it takes to walk across them. This is why Procedural Content Generation (PCG) is essential to reduce creation time and increase replayability.
Every game has its own unique story and gameplay, requiring different types of worlds. Traditional PCG methods often use square grids (like Minecraft) or hexagons (like Civilization). While these are computationally easy to handle, they obviously lack a natural feel. I wanted terrain that feels organic and looks natural, but follows consistent rules to satisfy my specific vision.
I wanted my world to be shaped in my way.
In this project, we explore a way to procedurally generate a 3D terrain using computational geometry:
First, we generate a 2D map, adapting ideas from Amit Patel’s Red Blob Games[1] and tweaking the logic to fit our specific goals.
Then, we prepare and convert the 2D map data into a heightmap for 3D terrain.
Finally, we transfer the data to Blender to render and build the cinematic world.
Stage 1: Chaos
It all starts with the chaos of random points.
To generate a 2D map, we first need small cells that essentially serve as the “pixels” or building blocks of our world. However, as mentioned earlier, usual square and hexagonal grids don’t result in realistic looking landscapes. Using polygons of varying shapes helps us generate a map that feels less like a machine made it and more organic.
So, we call upon our old friend: the Voronoi Diagram.
Voronoi Diagram
Given a point cloud \(S\) on a plane, a Voronoi Diagram partitions the plane into regions. For any given point \(p\), its Voronoi region consists of all points in the plane that are closer to \(p\) than to any other point. Mathematically:
\[
\text{Vor}(p) = \{x \in \mathbb{R}^2 \:| \: \|x-p\| \leq \|x-q\| \text{ for all } q \in S \}
\]
These regions create convex polygons that tile the plane. In the context of terrain generation, each of these Voronoi regions becomes a distinct territory: a patch of forest, a slice of ocean, or a mountain peak. To actually compute these regions in code, we turn to the dual of the Voronoi diagram: the Delaunay Triangulation.
Delaunay Triangulation
Delaunay Triangulation is defined as a triangulation of a set of points where no point is inside the circumcircle of any triangle. It is mathematically linked to the Voronoi Diagram via duality. If we connect every pair of points whose Voronoi regions share an edge, we get the Delaunay Triangulation. Conversely, the circumcenters of the Delaunay triangles serve as the vertices of the Voronoi diagram.
For the implementation, I used the popular existing library delaunator[2]. It computes the triangulation efficiently, allowing us to derive the Voronoi region boundaries from the triangle centers.
Figure 2: Delaunay Triangulation and Voronoi Regions for 100 random points.
As seen in Figure 2, the polygons look nice and random. However, they might be too random. True randomness is clumpy. We end up with tiny slivers of polygons right next to massive, stretched-out ones. This is too chaotic to be a skeleton for a game world. We want the terrain to be somewhat smoother and more uniform, mimicking the natural look of the real world. To fix the clumping, we use Lloyd’s Relaxation[3], also known as Voronoi iteration.
Lloyd’s Relaxation
Lloyd’s Relaxation is a simple algorithm that moves the random points to space them out evenly:
Compute the Voronoi Diagram of the current points.
Calculate the centroid of each Voronoi region.
Move the point to that centroid.
Repeat.
By repeating this process a few times, the points naturally space themselves out. After each iteration, the Voronoi cells become more uniform, ultimately resembling a turtle shell like pattern. I used the existing package lloyd[4] to handle this calculation.
Figure 3: Lloyd’s Relaxation after 1 and 3 iterations. Typically, 2–3 iterations are sufficient to stabilize the mesh.
Now, our grid is done, and it’s time to create the land.
Stage 2: Creation
We have our grid, which acts as the skeleton of our world. Now, we need to assign which tiles are land and which are ocean. To do this, I assign an altitude value to every Voronoi region. If the altitude is higher than a user-defined water level threshold, it becomes a land tile; otherwise, it is an ocean tile.
Again, we want the land to look random but natural. However, there is a huge problem with randomly assigning altitude. If we were to do that, the world would look like “TV static” because every tile would be independent of its neighbors. A deep ocean tile might sit right next to a high mountain peak, which is physically impossible. Real terrain is continuous and gradual: mountains roll into hills, and coastlines gradually descend into the ocean.
We need smooth randomness. To solve this, we use Perlin Noise[5].
Perlin Noise
Perlin Noise is an algorithm invented by Ken Perlin to generate natural-looking textures for movies. In 1997, Perlin received an Academy Award (Scientific and Technical Achievement) for the development of this algorithm [6].
Unlike standard random numbers, Perlin noise is coherent. If you pick a point on a Perlin noise map, the neighbor points around it will have very similar values, creating gradual transitions.
Figure 4: Side-by-side comparison: White noise (left) looks like static, while Perlin noise (right) looks like clouds or terrain.
As seen in Figure 4, the algorithm results in a cloud-like pattern that already looks like a map. For the implementation, I used the Python package pnoise[7].
After assigning Perlin noise as a base altitude, I made some customizations to shape the world exactly how I wanted. Raw Perlin noise doesn’t know by itself where an island should be. To fix this, I implemented a two-step shaping process:
Island Centers: I first scatter a specific number of “mountain peaks” (island centers) randomly across the grid. To ensure the land clusters around these peaks, I apply a distance penalty: the further a tile is from a mountain center, the lower its altitude becomes. This forces the noise to fade into the ocean as it gets further from the center, creating distinct island shapes rather than an infinite continent.
The Power Curve: Finally, I apply a non-linear adjustment to the height profile. I wanted flat, gentle slopes around the coastline for beaches and plains but much steeper, dramatic rises in the center. I achieved this by applying a power curve to the land values.
Show Python Implementation
def assignAltitudes(self):""" Generate terrain elevation using Perlin noise with island masking. The algorithm combines multi-octave Perlin noise with distance-based falloff from randomly placed island centers. A power curve is applied to create gentle coastal slopes transitioning to steeper mountain peaks. """ scaled_pts =self.points /self.grid_size *self.noise_scale noise_vals = np.array([(pnoise2(x, y, octaves=6) +1) /2for x, y in scaled_pts])# Randomly place island centers (avoiding edges) island_centers = np.random.uniform(0.2, 0.8, (self.cluster, 2)) *self.grid_size# Distance falloff: points far from island centers become ocean all_dists = np.array([np.linalg.norm(self.points - c, axis=1) for c in island_centers]) distances = np.min(all_dists, axis=0) # Distance to nearest island center normalized_dists = distances / (self.grid_size /2)# Combine noise with distance mask (quadratic falloff) base_alt = noise_vals - normalized_dists **2# Apply power curve to land for realistic elevation profile land_mask = base_alt >self.water_levelself.altitudes = base_alt.copy() land_vals = base_alt[land_mask] land_normalized = (land_vals -self.water_level) / (1.0-self.water_level) land_normalized = np.clip(land_normalized, 0, 1)# Piecewise curve: gentle near coast, steeper inland for mountains sharpened = np.where( land_normalized <0.35, land_normalized *0.8, # Gentle coastal slope0.28+ (land_normalized -0.35) **1.3*3# Steeper mountain peaks )self.altitudes[land_mask] =self.water_level + sharpened * (1.0-self.water_level)
Biome Assignment
With the heightmap complete, we have a basic definition of land and sea.
Figure 5: Land and Ocean tiles are assigned
Figure 5 is a convincing start, it resembles a landmass. However, the land is all one color and boring. Real-world terrain is vibrant: we have forests, deserts, snowy peaks, and swamps. In short, we have biomes.
Our initial strategy relied solely on altitude. While altitude separates ocean from land, it isn’t enough to distinguish a Desert from a Rainforest. They might be at the same height, but they look completely different. The missing variable is Moisture. To simulate this, I generated a second Perlin Noise map specifically for moisture. I then applied environmental modifiers to make it physically plausible:
Proximity to water: Tiles closer to the ocean received a moisture boost.
Elevation Adjustment: High-altitude areas generally became drier, though I added a special exception to ensure the very highest peaks retained enough “moisture” value to support snow caps.
By combining these two values (Altitude and Moisture), we can classify every tile into a specific biome. I implemented a classification similar to Red Blob Games [1], resulting in 10 distinct biome types. For example:
High Altitude + Low Moisture \(\rightarrow\) Scorched / Rocky Mountain
High Altitude + High Moisture \(\rightarrow\) Snow
Mid Altitude + High Moisture \(\rightarrow\) Forest
def get_biome_color(self, e, m):""" Returns color based on elevation (e) and moisture (m). Both e and m are normalized to 0.0 - 1.0. """if e <0.1:returnself.BIOME_COLORS['BEACH']if e >0.85:returnself.BIOME_COLORS['SNOW']if e >0.60:if m <0.4:returnself.BIOME_COLORS['MOUNTAIN'] if m <0.7:returnself.BIOME_COLORS['TUNDRA'] returnself.BIOME_COLORS['SNOW'] if e >0.35:if m <0.4:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['TAIGA'] if e >0.25:if m <0.3:returnself.BIOME_COLORS['DESERT'] if m <0.6:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['FOREST'] if m <0.3:returnself.BIOME_COLORS['DESERT'] if m <0.6:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['RAINFOREST']
Figure 6: Terrain with biome colorings
As seen in Figure 6, the map is now visually diverse. We see sandy beaches along the coastline, green forests fill midlands, and white snow caps the highest peaks in the center.
2D Interactive Playground
Up to this point, I was happy with the logic of my 2D map. However, tuning the parameters to get the exact look I wanted required endless tweaking. It became incredibly annoying to re-run the entire script every time I wanted to change a noise value by \(0.1\). So, I built an interactive dashboard to visualize the changes in real-time. Since the tool was so useful for debugging, I decided to host it publicly on Streamlit so you can try it yourself.
The app gives you control over the key generation parameters:
Random Seed: Change this to generate a completely different world layout.
Noise Scale: Controls the “zoom” of the features. Higher values create chaotic, fragmented terrain; lower values create massive continents.
Water Level: Raises or lowers the ocean. Higher values result in archipelagos; lower values create super-continents.
Island Clusters: Determines the number of mountain peaks (island centers) to generate.
Resolution: The number of Voronoi cells. More points = finer detail (but slower generation).
Figure 7: Screenshot of the Streamlit app
With this tool in hand, the 2D phase of the project is complete. We have the map, we have the biomes, and we have the controls. Now, it is time to add the third dimension.
Stage 3: Continents
In the 2D map, I assigned colors based on the altitude of each Voronoi region. However, a flat map can only tell us so much. To fully utilize the altitude data and satisfy my curiosity about what this world would look like as a real landscape, I decided to move into the third dimension.
I chose Blender, a free and open-source 3D creation software, to render the map. Blender is a widely used tool for visual effects and modeling. Notably, the animated film Flow, which won an Academy Award in 2025, was created using Blender [8].
Transfer to 3D
To use Blender, I first needed to export my Python data into a format the software could understand. Blender generates terrain using a Heightmap: a grayscale image where black represents the ocean floor and white represents the highest mountain peaks. It also requires a Colormap to paint the surface.
I wrote a script using the Python Imaging Library (PIL/Pillow)[9] to export these images directly from my generation code.
Show Heightmap Export Code
# Normalize altitudes to 0.0 - 1.0 rangeh_min, h_max = heights.min(), heights.max()heights = (heights - h_min) / (h_max - h_min) # Transform to 0-255 integer range for image pixelsheights = (heights *255).astype(np.uint8) # Flip upside down (Blender's UV coordinates are often flipped relative to arrays)heights = np.flipud(heights) # Save the image as Grayscale (Mode 'L')img = Image.fromarray(heights, mode="L") img.save("heightmap.png")
Figure 8: The exported heightmap and colormap
Figure 8 shows the raw output of this process. The grayscale image controls the geometry, while the colored map paints the surface.
3D Result
After importing these images into Blender and applying a Displacement Modifier to a plane, the flat grid transformed into a rough terrain.
As seen in the video, moving to 3D reveals details that were invisible in 2D. We can see the steepness of the cliffs, the rolling nature of the hills, and how the light interacts with the geometry to create shadows and depth. With this, I’m quite happy with what I accomplished before the semester ends.
Discussion and Conclusion
Key Insights
Through this project, I learned that making a world is a mix of math and art. I was surprised to see that pure randomness is actually ugly. The Voronoi diagram gave the map a shape, but Perlin noise made it smooth. You really need both to make it look real. Also, Lloyd’s Relaxation was the small but key step. I didn’t expect it to change much, but it turned a messy bunch of polygons into a clean, nice-looking grid that still felt organic.
Finally, working in 2D was good for starting and fixing the code, but putting it into Blender finally showed me how the world really feels. However, due to the steep learning curve of Blender, I could do only a little with it within the deadline.
Limitations and Future Work
Even though the results look cool, there are a few things I wish I could fix with more time. Right now, moisture is just a number on a tile. It doesn’t change. Rivers flowing and cutting into the land would have given the map more life. Further, my biomes only look at height and moisture. Real nature is more complex, involving wind and temperature changes which also cause more gradual shifts between biomes.
As I made the map bigger with more points, the code got slower. Python is easy to write and NumPy is fast for some operations, but for a huge game world, I might need a faster language like C++. In the future, I want to add erosion to make real rivers and put the 3D model into a game engine like Unity. That way, I could actually walk around in the world I built.
Final Summary
At the start, I said that making worlds by hand takes too long and grids look too blocky. By using Voronoi diagrams instead of squares, and Perlin noise to shape the land, we built a system that makes infinite, natural-looking continents.
This project explored the intersection of math and art, and the potential of what they have given us and what they will. In the end, I shaped the world in my way.
Note
All code and project files are available at the GitHub repository.
K. Perlin, “An image synthesizer,”ACM Siggraph Computer Graphics, vol. 19, no. 3, pp. 287–296, 1985.
[6]
Academy of Motion Picture Arts and Sciences, “Scientific and technical award (technical achievement award) for perlin noise.”https://cs.nyu.edu/~perlin/doc/oscar.html, 1997.
---title: "From Chaos to Continents"subtitle: "Procedural Terrain Generation Using Voronoi Diagrams and More"author: "Batsambuu Batbold"date: "December 16 2025"abstract: | Hand-crafting a massive video game world is an incredibly time-consuming task. Is there a way to let the computer build it for us? In this project, we explore Procedural Content Generation, using algorithms to create unique landscapes automatically. While many traditional approaches use square grids, these often look blocky and artificial. We aim to fix this by creating terrain that feels organic. We replace rigid squares with Voronoi diagrams to create natural shapes, and we use Perlin noise to generate realistic mountains and valleys. We also implement a system to assign biomes, such as forests and deserts, based on the simulation of height and moisture. Finally, we bring the map to life by rendering it in 3D using Blender. This project demonstrates how we can turn mathematics into explorable worlds.bibliography: references.bibcsl: https://www.zotero.org/styles/ieeefontsize: 1.2emformat: html: theme: cosmo toc: true toc_float: true toc_depth: 3 self-contained: true code-tools: true code-fold: true---{#fig-delaunay}[TODO: Include final 3D screenshot]# IntroductionAs a fan of open-world and simulation video games, the setting and environment are the biggest factors in deciding if a virtual world is worth my time. We, as players, expect vast, immersive, and explorable worlds that offer a unique experience. Who wants to keep coming back to the same place if it offers nothing new? However, hand-crafting these worlds is a huge task. It often takes longer to build than it takes to walk across them. This is why **Procedural Content Generation** (PCG) is essential to reduce creation time and increase replayability.Every game has its own unique story and gameplay, requiring different types of worlds. Traditional PCG methods often use square grids (like Minecraft) or hexagons (like Civilization). While these are computationally easy to handle, they obviously lack a natural feel. I wanted terrain that feels organic and looks natural, but follows consistent rules to satisfy my specific vision.**I wanted *my world* to be shaped in *my way*.**In this project, we explore a way to procedurally generate a 3D terrain using computational geometry:1. First, we generate a 2D map, adapting ideas from Amit Patel's **Red Blob Games** [@patel2015polygonal] and tweaking the logic to fit our specific goals.2. Then, we prepare and convert the 2D map data into a heightmap for 3D terrain.3. Finally, we transfer the data to Blender to render and build the cinematic world.---# Stage 1: ChaosIt all starts with the chaos of random points.To generate a 2D map, we first need small cells that essentially serve as the "pixels" or building blocks of our world. However, as mentioned earlier, usual square and hexagonal grids don’t result in realistic looking landscapes. Using polygons of varying shapes helps us generate a map that feels less like a machine made it and more organic.So, we call upon our old friend: the **Voronoi Diagram**.## Voronoi DiagramGiven a point cloud $S$ on a plane, a Voronoi Diagram partitions the plane into regions. For any given point $p$, its Voronoi region consists of all points in the plane that are closer to $p$ than to any other point. Mathematically:$$\text{Vor}(p) = \{x \in \mathbb{R}^2 \:| \: \|x-p\| \leq \|x-q\| \text{ for all } q \in S \}$$These regions create convex polygons that tile the plane. In the context of terrain generation, each of these Voronoi regions becomes a distinct territory: a patch of forest, a slice of ocean, or a mountain peak. To actually compute these regions in code, we turn to the dual of the Voronoi diagram: the **Delaunay Triangulation**.## Delaunay TriangulationDelaunay Triangulation is defined as a triangulation of a set of points where no point is inside the circumcircle of any triangle. It is mathematically linked to the Voronoi Diagram via duality. If we connect every pair of points whose Voronoi regions share an edge, we get the Delaunay Triangulation. Conversely, the circumcenters of the Delaunay triangles serve as the vertices of the Voronoi diagram.For the implementation, I used the popular existing library `delaunator`[@delaunator]. It computes the triangulation efficiently, allowing us to derive the Voronoi region boundaries from the triangle centers.{#fig-vor-del}As seen in @fig-vor-del, the polygons look nice and random. However, they might be *too random*. True randomness is clumpy. We end up with tiny slivers of polygons right next to massive, stretched-out ones. This is too chaotic to be a skeleton for a game world. We want the terrain to be somewhat smoother and more uniform, mimicking the natural look of the real world. To fix the clumping, we use **Lloyd's Relaxation** [@lloyd_wolfram], also known as Voronoi iteration.## Lloyd's RelaxationLloyd's Relaxation is a simple algorithm that moves the random points to space them out evenly:1. Compute the Voronoi Diagram of the current points.2. Calculate the centroid of each Voronoi region.3. Move the point to that centroid.4. Repeat.By repeating this process a few times, the points naturally space themselves out. After each iteration, the Voronoi cells become more uniform, ultimately resembling a turtle shell like pattern. I used the existing package `lloyd`[@lloyd_repo] to handle this calculation.{#fig-lloyd}Now, our grid is done, and it's time to create the land.---# Stage 2: CreationWe have our grid, which acts as the skeleton of our world. Now, we need to assign which tiles are land and which are ocean. To do this, I assign an altitude value to every Voronoi region. If the altitude is higher than a user-defined water level threshold, it becomes a land tile; otherwise, it is an ocean tile.Again, we want the land to look random but natural. However, there is a huge problem with randomly assigning altitude. If we were to do that, the world would look like "TV static" because every tile would be independent of its neighbors. A deep ocean tile might sit right next to a high mountain peak, which is physically impossible. Real terrain is continuous and gradual: mountains roll into hills, and coastlines gradually descend into the ocean.We need *smooth randomness*. To solve this, we use **Perlin Noise** [@perlin1985].## Perlin NoisePerlin Noise is an algorithm invented by Ken Perlin to generate natural-looking textures for movies. In 1997, Perlin received an Academy Award (Scientific and Technical Achievement) for the development of this algorithm [@oscar_perlin].Unlike standard random numbers, Perlin noise is coherent. If you pick a point on a Perlin noise map, the neighbor points around it will have very similar values, creating gradual transitions.{#fig-noise}As seen in @fig-noise, the algorithm results in a cloud-like pattern that already looks like a map. For the implementation, I used the Python package **pnoise** [@pnoise].After assigning Perlin noise as a base altitude, I made some customizations to shape the world exactly how I wanted. Raw Perlin noise doesn't know by itself where an island should be. To fix this, I implemented a two-step shaping process:* **Island Centers**: I first scatter a specific number of "mountain peaks" (island centers) randomly across the grid. To ensure the land clusters around these peaks, I apply a distance penalty: the further a tile is from a mountain center, the lower its altitude becomes. This forces the noise to fade into the ocean as it gets further from the center, creating distinct island shapes rather than an infinite continent.* **The Power Curve**: Finally, I apply a non-linear adjustment to the height profile. I wanted flat, gentle slopes around the coastline for beaches and plains but much steeper, dramatic rises in the center. I achieved this by applying a power curve to the land values.```{python}#| eval: false#| code-fold: true#| code-summary: "Show Python Implementation"def assignAltitudes(self):""" Generate terrain elevation using Perlin noise with island masking. The algorithm combines multi-octave Perlin noise with distance-based falloff from randomly placed island centers. A power curve is applied to create gentle coastal slopes transitioning to steeper mountain peaks. """ scaled_pts =self.points /self.grid_size *self.noise_scale noise_vals = np.array([(pnoise2(x, y, octaves=6) +1) /2for x, y in scaled_pts])# Randomly place island centers (avoiding edges) island_centers = np.random.uniform(0.2, 0.8, (self.cluster, 2)) *self.grid_size# Distance falloff: points far from island centers become ocean all_dists = np.array([np.linalg.norm(self.points - c, axis=1) for c in island_centers]) distances = np.min(all_dists, axis=0) # Distance to nearest island center normalized_dists = distances / (self.grid_size /2)# Combine noise with distance mask (quadratic falloff) base_alt = noise_vals - normalized_dists **2# Apply power curve to land for realistic elevation profile land_mask = base_alt >self.water_levelself.altitudes = base_alt.copy() land_vals = base_alt[land_mask] land_normalized = (land_vals -self.water_level) / (1.0-self.water_level) land_normalized = np.clip(land_normalized, 0, 1)# Piecewise curve: gentle near coast, steeper inland for mountains sharpened = np.where( land_normalized <0.35, land_normalized *0.8, # Gentle coastal slope0.28+ (land_normalized -0.35) **1.3*3# Steeper mountain peaks )self.altitudes[land_mask] =self.water_level + sharpened * (1.0-self.water_level)```## Biome AssignmentWith the heightmap complete, we have a basic definition of land and sea.{#fig-land}@fig-land is a convincing start, it resembles a landmass. However, the land is all one color and boring. Real-world terrain is vibrant: we have forests, deserts, snowy peaks, and swamps. In short, we have **biomes**. Our initial strategy relied solely on altitude. While altitude separates ocean from land, it isn't enough to distinguish a Desert from a Rainforest. They might be at the same height, but they look completely different. The missing variable is Moisture. To simulate this, I generated a second Perlin Noise map specifically for moisture. I then applied environmental modifiers to make it physically plausible:* **Proximity to water**: Tiles closer to the ocean received a moisture boost.* **Elevation Adjustment**: High-altitude areas generally became drier, though I added a special exception to ensure the very highest peaks retained enough "moisture" value to support snow caps.By combining these two values (Altitude and Moisture), we can classify every tile into a specific biome. I implemented a classification similar to Red Blob Games [@patel2015polygonal], resulting in 10 distinct biome types. For example:* High Altitude + Low Moisture $\rightarrow$ Scorched / Rocky Mountain* High Altitude + High Moisture $\rightarrow$ Snow* Mid Altitude + High Moisture $\rightarrow$ Forest* Low Altitude + Low Moisture $\rightarrow$ Desert```{python}#| eval: false#| code-fold: true#| code-summary: "Show Classification Logic"def get_biome_color(self, e, m):""" Returns color based on elevation (e) and moisture (m). Both e and m are normalized to 0.0 - 1.0. """if e <0.1:returnself.BIOME_COLORS['BEACH']if e >0.85:returnself.BIOME_COLORS['SNOW']if e >0.60:if m <0.4:returnself.BIOME_COLORS['MOUNTAIN'] if m <0.7:returnself.BIOME_COLORS['TUNDRA'] returnself.BIOME_COLORS['SNOW'] if e >0.35:if m <0.4:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['TAIGA'] if e >0.25:if m <0.3:returnself.BIOME_COLORS['DESERT'] if m <0.6:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['FOREST'] if m <0.3:returnself.BIOME_COLORS['DESERT'] if m <0.6:returnself.BIOME_COLORS['GRASSLAND'] returnself.BIOME_COLORS['RAINFOREST'] ```{#fig-biomes}As seen in @fig-biomes, the map is now visually diverse. We see sandy beaches along the coastline, green forests fill midlands, and white snow caps the highest peaks in the center.---## 2D Interactive PlaygroundUp to this point, I was happy with the logic of my 2D map. However, tuning the parameters to get the exact look I wanted required endless tweaking. It became incredibly annoying to re-run the entire script every time I wanted to change a noise value by $0.1$. So, I built an interactive dashboard to visualize the changes in real-time. Since the tool was so useful for debugging, I decided to host it publicly on Streamlit so you can try it yourself.[**🔗 Click Here to Try the Interactive Map Generator**](https://basabu1-map-generation-app-fut9qy.streamlit.app/)The app gives you control over the key generation parameters:* **Random Seed**: Change this to generate a completely different world layout.* **Noise Scale**: Controls the "zoom" of the features. Higher values create chaotic, fragmented terrain; lower values create massive continents.* **Water Level**: Raises or lowers the ocean. Higher values result in archipelagos; lower values create super-continents.* **Island Clusters**: Determines the number of mountain peaks (island centers) to generate.* **Resolution**: The number of Voronoi cells. More points = finer detail (but slower generation).{#fig-app}With this tool in hand, the 2D phase of the project is complete. We have the map, we have the biomes, and we have the controls. Now, it is time to add the third dimension.---# Stage 3: ContinentsIn the 2D map, I assigned colors based on the altitude of each Voronoi region. However, a flat map can only tell us so much. To fully utilize the altitude data and satisfy my curiosity about what this world would look like as a real landscape, I decided to move into the third dimension.I chose **Blender**, a free and open-source 3D creation software, to render the map. Blender is a widely used tool for visual effects and modeling. Notably, the animated film *Flow*, which won an Academy Award in 2025, was created using Blender [@oscar_2025].## Transfer to 3DTo use Blender, I first needed to export my Python data into a format the software could understand. Blender generates terrain using a **Heightmap**: a grayscale image where black represents the ocean floor and white represents the highest mountain peaks. It also requires a **Colormap** to paint the surface.I wrote a script using the **Python Imaging Library (PIL/Pillow)** [@pillow] to export these images directly from my generation code. ```{python}#| eval: false#| code-fold: true#| code-summary: "Show Heightmap Export Code"# Normalize altitudes to 0.0 - 1.0 rangeh_min, h_max = heights.min(), heights.max()heights = (heights - h_min) / (h_max - h_min) # Transform to 0-255 integer range for image pixelsheights = (heights *255).astype(np.uint8) # Flip upside down (Blender's UV coordinates are often flipped relative to arrays)heights = np.flipud(heights) # Save the image as Grayscale (Mode 'L')img = Image.fromarray(heights, mode="L") img.save("heightmap.png")```{#fig-export}@fig-export shows the raw output of this process. The grayscale image controls the geometry, while the colored map paints the surface.## 3D ResultAfter importing these images into Blender and applying a Displacement Modifier to a plane, the flat grid transformed into a rough terrain.{{< video videos/flyover.mp4 >}}As seen in the video, moving to 3D reveals details that were invisible in 2D. We can see the steepness of the cliffs, the rolling nature of the hills, and how the light interacts with the geometry to create shadows and depth. With this, I'm quite happy with what I accomplished before the semester ends.---# Discussion and Conclusion## Key InsightsThrough this project, I learned that making a world is a mix of math and art. I was surprised to see that pure randomness is actually ugly. The Voronoi diagram gave the map a shape, but Perlin noise made it smooth. You really need both to make it look real. Also, Lloyd's Relaxation was the small but key step. I didn't expect it to change much, but it turned a messy bunch of polygons into a clean, nice-looking grid that still felt organic. Finally, working in 2D was good for starting and fixing the code, but putting it into Blender finally showed me how the world really feels. However, due to the steep learning curve of Blender, I could do only a little with it within the deadline.## Limitations and Future WorkEven though the results look cool, there are a few things I wish I could fix with more time. Right now, moisture is just a number on a tile. It doesn't change. Rivers flowing and cutting into the land would have given the map more life. Further, my biomes only look at height and moisture. Real nature is more complex, involving wind and temperature changes which also cause more gradual shifts between biomes. As I made the map bigger with more points, the code got slower. Python is easy to write and NumPy is fast for some operations, but for a huge game world, I might need a faster language like C++. In the future, I want to add erosion to make real rivers and put the 3D model into a game engine like Unity. That way, I could actually walk around in the world I built.## Final SummaryAt the start, I said that making worlds by hand takes too long and grids look too blocky. By using Voronoi diagrams instead of squares, and Perlin noise to shape the land, we built a system that makes infinite, natural-looking continents.This project explored the intersection of math and art, and the potential of what they have given us and what they will. In the end, I shaped the world in my way.## NoteAll code and project files are available at the GitHub [repository](https://github.com/BaSaBu1/Map-Generation). ---# References::: {#refs}:::